<!doctype html><html lang=zh-hans>
<head>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<meta http-equiv=x-ua-compatible content="IE=edge">
<meta name=author content="小李刀刀">
<meta name=description content="Spiral 框架包含了大量的组件，这些组件各自承担不同的职责，彼此之间相互协同，紧密合作，从而构建出各种复杂的应用。 在本文中，我会通过一个博客应用示例，向大家演示 Rest API、ORM（对象关系映射）、migrations（数据迁移）、request validation（请求校验）、custom">
<meta name=theme-color content="#3f51b5">
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/academicons/1.8.6/css/academicons.min.css integrity="sha256-uFVgMKfistnJAfoCUQigIl+JfUaP47GrRKjf6CTPVmw=" crossorigin=anonymous>
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.12.0-1/css/all.min.css integrity="sha256-4w9DunooKSr3MFXHXWyFER38WmPdm361bQS/2KUWZbU=" crossorigin=anonymous>
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.7/jquery.fancybox.min.css integrity="sha256-Vzbj7sDDS/woiFS3uNKo8eIuni59rjyNGtXfstRzStA=" crossorigin=anonymous>
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/styles/tomorrow-night.min.css crossorigin=anonymous title=hl-light>
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/styles/tomorrow-night.min.css crossorigin=anonymous title=hl-dark disabled>
<script src=https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.1.2/lazysizes.min.js integrity="sha256-Md1qLToewPeKjfAHU1zyPwOutccPAm5tahnaw7Osw0A=" crossorigin=anonymous async></script>
<link rel=stylesheet href="https://fonts.font.im/css?family=Source+Code+Pro:300,400,500,600,700&subset=latin-ext&display=swap">
<link rel=stylesheet href=/css/academic.css>
<script>(function(b,d,e,a,g){b[a]=b[a]||[],b[a].push({'gtm.start':(new Date).getTime(),event:'gtm.js'});var f=d.getElementsByTagName(e)[0],c=d.createElement(e),h=a!='dataLayer'?'&l='+a:'';c.async=!0,c.src='https://www.googletagmanager.com/gtm.js?id='+g+h,f.parentNode.insertBefore(c,f)})(window,document,'script','dataLayer','GTM-5KZH8N7')</script>
<link rel=manifest href=/index.webmanifest>
<link rel=icon type=image/png href=/images/icon_hu0b7a4cb9992c9ac0e91bd28ffd38dd00_9727_32x32_fill_lanczos_center_3.png>
<link rel=apple-touch-icon type=image/png href=/images/icon_hu0b7a4cb9992c9ac0e91bd28ffd38dd00_9727_192x192_fill_lanczos_center_3.png>
<link rel=canonical href=https://studyspiral.cn/docs/basics/quick-start/>
<meta property="twitter:card" content="summary">
<meta property="og:site_name" content="Spiral中文网">
<meta property="og:url" content="https://studyspiral.cn/docs/basics/quick-start/">
<meta property="og:title" content="上手指南 | Spiral中文网">
<meta property="og:description" content="Spiral 框架包含了大量的组件，这些组件各自承担不同的职责，彼此之间相互协同，紧密合作，从而构建出各种复杂的应用。 在本文中，我会通过一个博客应用示例，向大家演示 Rest API、ORM（对象关系映射）、migrations（数据迁移）、request validation（请求校验）、custom"><meta property="og:image" content="https://studyspiral.cn/images/logo.svg">
<meta property="twitter:image" content="https://studyspiral.cn/images/logo.svg"><meta property="og:locale" content="zh-Hans">
<meta property="article:published_time" content="2020-04-12T23:11:23+08:00">
<meta property="article:modified_time" content="2020-04-13T00:00:42+08:00">
<script src=https://cdnjs.cloudflare.com/ajax/libs/cookieconsent2/3.1.1/cookieconsent.min.js integrity="sha256-5VhCqFam2Cn+yjw61zbBNrbHVJ6SRydPeKopYlngbiQ=" crossorigin=anonymous></script>
<link rel=stylesheet href=https://cdnjs.cloudflare.com/ajax/libs/cookieconsent2/3.1.1/cookieconsent.min.css integrity="sha256-zQ0LblD/Af8vOppw18+2anxsuaz3pWYyVWi+bTvTH8Q=" crossorigin=anonymous>
<script>window.addEventListener("load",function(){window.cookieconsent.initialise({palette:{popup:{background:"#3f51b5",text:"#fff"},button:{background:"#fff",text:"#3f51b5"}},theme:"classic",content:{message:"本网站使用cookies来确保您在本网站上获得最佳体验。",dismiss:"知道了!",link:"了解更多",href:"https://www.cookiesandyou.com"}})})</script>
<title>上手指南 | Spiral中文网</title>
</head>
<body id=top data-spy=scroll data-offset=70 data-target=#TableOfContents>
<aside class=search-results id=search>
<div class=container>
<section class=search-header>
<div class="row no-gutters justify-content-between mb-3">
<div class=col-6>
<h1>搜索</h1>
</div>
<div class="col-6 col-search-close">
<a class=js-search href=#><i class="fas fa-times-circle text-muted" aria-hidden=true></i></a>
</div>
</div>
<div id=search-box>
<input name=q id=search-query placeholder=搜索... autocapitalize=off autocomplete=off autocorrect=off spellcheck=false type=search>
</div>
</section>
<section class=section-search-results>
<div id=search-hits>
</div>
</section>
</div>
</aside>
<nav class="navbar navbar-expand-lg navbar-light compensate-for-scrollbar" id=navbar-main>
<div class=container>
<div class="d-none d-lg-inline-flex">
<a class=navbar-brand href=/><img src=/images/logo.svg alt=Spiral中文网>Spiral中文网</a>
</div>
<button type=button class=navbar-toggler data-toggle=collapse data-target=#navbar-content aria-controls=navbar aria-expanded=false aria-label=切换导航>
<span><i class="fas fa-bars"></i></span>
</button>
<div class="navbar-brand-mobile-wrapper d-inline-flex d-lg-none">
<a class=navbar-brand href=/><img src=/images/logo.svg alt=Spiral中文网>Spiral中文网</a>
</div>
<div class="navbar-collapse main-menu-item collapse justify-content-end" id=navbar-content>
<ul class="navbar-nav d-md-inline-flex">
<li class=nav-item>
<a class="nav-link active" href=/docs/basics/quick-start/><span>教程</span></a>
</li>
<li class=nav-item>
<a class="nav-link active" href=/docs/><span>文档</span></a>
</li>
<li class=nav-item>
<a class=nav-link href=/post/><span>文章</span></a>
</li>
</ul>
</div>
<ul class="nav-icons navbar-nav flex-row ml-auto d-flex pl-md-2">
<li class=nav-item>
<a class="nav-link js-search" href=#><i class="fas fa-search" aria-hidden=true></i></a>
</li>
</ul>
</div>
</nav>
<div class="container-fluid docs">
<div class="row flex-xl-nowrap">
<div class="col-12 col-md-3 col-xl-2 docs-sidebar">
<form class="docs-search d-flex align-items-center">
<button class="btn docs-toggle d-md-none p-0 mr-3" type=button data-toggle=collapse data-target=#docs-nav aria-controls=docs-nav aria-expanded=false aria-label="Toggle section navigation">
<span><i class="fas fa-bars"></i></span>
</button>
<input name=q type=search class=form-control placeholder=搜索... autocomplete=off>
</form>
<nav class="collapse docs-links" id=docs-nav>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/extension/dotenv/>Dotenv</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/>总览</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse show">
<li>
<a href=/docs/>Spiral介绍</a>
</li>
<li>
<a href=/docs/about/contributing/>贡献指引</a>
</li>
<li>
<a href=/docs/about/semver/>版本说明</a>
</li>
<li>
<a href=/docs/about/license/>授权协议</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/http/psr-15/>定制 PSR-15 处理器</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/start/install/>快速开始</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/start/install/>安装指引</a>
</li>
<li>
<a href=/docs/start/workers/>应用工作进程</a>
</li>
<li>
<a href=/docs/start/structure/>应用程序结构</a>
</li>
<li>
<a href=/docs/start/configuration/>默认配置</a>
</li>
<li>
<a href=/docs/start/commands/>控制台命令</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/basics/quick-start/>入门基础</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse show">
<li class=active>
<a href=/docs/basics/quick-start/>上手指南</a>
</li>
<li>
<a href=/docs/basics/scaffolding/>脚手架</a>
</li>
<li>
<a href=/docs/basics/prototype/>原型开发辅助</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/framework/design/>核心框架</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/framework/design/>设计理念</a>
</li>
<li>
<a href=/docs/framework/application-server/>应用服务器</a>
</li>
<li>
<a href=/docs/framework/config/>配置对象</a>
</li>
<li>
<a href=/docs/framework/kernel/>内核与环境</a>
</li>
<li>
<a href=/docs/framework/container/>容器</a>
</li>
<li>
<a href=/docs/framework/bootloaders/>引导程序</a>
</li>
<li>
<a href=/docs/framework/scopes/>IoC 作用域</a>
</li>
<li>
<a href=/docs/framework/memory/>静态高速缓存</a>
</li>
<li>
<a href=/docs/framework/finalizers/>终结器</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/cookbook/annotated-routes/>速查手册</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/cookbook/annotated-routes/>注解式路由</a>
</li>
<li>
<a href=/docs/cookbook/injector/>容器注入器</a>
</li>
<li>
<a href=/docs/cookbook/domain-core/>领域内核、控制器</a>
</li>
<li>
<a href=/docs/cookbook/golang-library/>Golang服务集成</a>
</li>
<li>
<a href=/docs/cookbook/custom-dispatcher/>自定义调度器</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/component/files/>常用组件</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/component/files/>文件和目录</a>
</li>
<li>
<a href=/docs/component/reactor/>代码生成</a>
</li>
<li>
<a href=/docs/component/tokenizer/>静态分析工具</a>
</li>
<li>
<a href=/docs/component/metrics/>应用指标</a>
</li>
<li>
<a href=/docs/component/data-grid/>数据网格</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/console/configuration/>控制台</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/console/configuration/>安装和配置</a>
</li>
<li>
<a href=/docs/console/commands/>用户命令</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/http/configuration/>HTTP</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/http/configuration/>安装配置</a>
</li>
<li>
<a href=/docs/http/lifecycle/>请求生命周期</a>
</li>
<li>
<a href=/docs/http/request-response/>请求和响应</a>
</li>
<li>
<a href=/docs/http/routing/>路由</a>
</li>
<li>
<a href=/docs/http/errors/>错误页面</a>
</li>
<li>
<a href=/docs/http/middleware/>中间件</a>
</li>
<li>
<a href=/docs/http/golang/>Golang 中间件</a>
</li>
<li>
<a href=/docs/http/cookies/>Cookies</a>
</li>
<li>
<a href=/docs/http/session/>Session</a>
</li>
<li>
<a href=/docs/http/csrf/>CSRF 防护</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/security/encrypter/>安全</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/security/encrypter/>数据加密</a>
</li>
<li>
<a href=/docs/security/validation/>数据验证</a>
</li>
<li>
<a href=/docs/security/rbac/>基于角色的权限控制</a>
</li>
<li>
<a href=/docs/security/authentication/>用户认证</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/filters/configuration/>请求过滤</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/filters/configuration/>安装和配置</a>
</li>
<li>
<a href=/docs/filters/filter/>过滤器</a>
</li>
<li>
<a href=/docs/filters/composite/>复合过滤器</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/database/configuration/>数据库</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/database/configuration/>安装与配置</a>
</li>
<li>
<a href=/docs/database/access/>访问数据</a>
</li>
<li>
<a href=/docs/database/isolation/>逻辑隔离</a>
</li>
<li>
<a href=/docs/database/query-builders/>查询构造器</a>
</li>
<li>
<a href=/docs/database/transactions/>Transactions</a>
</li>
<li>
<a href=/docs/database/introspection/>Schema Introspection</a>
</li>
<li>
<a href=/docs/database/declaration/>Declaration</a>
</li>
<li>
<a href=/docs/database/migrations/>Migrations</a>
</li>
<li>
<a href=/docs/database/errata/>Errata</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/cycle/configuration/>Cycle ORM</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/cycle/configuration/>Configuration</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/queue/configuration/>队列任务</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/queue/configuration/>Configuration</a>
</li>
<li>
<a href=/docs/queue/scraper/>网站爬虫</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/views/configuration/>视图</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/views/configuration/>Configuration</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link href=/docs/stempler/configuration/>Stempler 模板</a>
<ul class="nav docs-sidenav docs-sidenav-sub collapse">
<li>
<a href=/docs/stempler/configuration/>Configuration</a>
</li>
<li>
<a href=/docs/stempler/directives/>Directives</a>
</li>
</ul>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link>国际化</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link>GRPC</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link>事件广播</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link>调试及性能</a>
</div>
<div class=docs-toc-item>
<a class=docs-toc-link>扩展</a>
</div>
</nav>
</div>
<div class="d-none d-xl-block col-xl-2 docs-toc">
<ul class="nav toc-top">
<li><a href=# id=back_to_top class=docs-toc-title>在本页</a></li>
</ul>
<nav id=TableOfContents>
<ul>
<li><a href=#安装>安装</a></li>
<li><a href=#配置>配置</a>
<ul>
<li><a href=#开发模式>开发模式</a></li>
<li><a href=#清理项目>清理项目</a></li>
<li><a href=#数据库连接>数据库连接</a></li>
<li><a href=#假数据>假数据</a></li>
<li><a href=#路由>路由</a></li>
<li><a href=#注解式路由>注解式路由</a></li>
<li><a href=#注解>注解</a></li>
<li><a href=#领域内核>领域内核</a></li>
</ul>
</li>
<li><a href=#数据库脚手架>数据库脚手架</a>
<ul>
<li><a href=#定义-orm-实体>定义 ORM 实体</a></li>
<li><a href=#生成数据库迁移文件>生成数据库迁移文件</a></li>
<li><a href=#创建实体关系>创建实体关系</a></li>
</ul>
</li>
<li><a href=#服务>服务</a>
<ul>
<li><a href=#原型开发辅助>原型开发辅助</a></li>
</ul>
</li>
<li><a href=#控制台命令>控制台命令</a>
<ul>
<li><a href=#usercommand>UserCommand</a></li>
<li><a href=#postcommand>PostCommand</a></li>
<li><a href=#commentcommand>CommentCommand</a></li>
</ul>
</li>
<li><a href=#控制器-1>控制器</a>
<ul>
<li><a href=#测试方法>测试方法</a></li>
<li><a href=#获取文章数据>获取文章数据</a></li>
<li><a href=#post-视图映射>Post 视图映射</a></li>
<li><a href=#获取文章列表>获取文章列表</a></li>
<li><a href=#数据网格data-grid>数据网格（Data Grid）</a></li>
</ul>
</li>
<li><a href=#校验请求>校验请求</a>
<ul>
<li><a href=#评论服务类>评论服务类</a></li>
<li><a href=#控制器方法>控制器方法</a></li>
<li><a href=#执行和验证>执行和验证</a></li>
</ul>
</li>
<li><a href=#渲染模板>渲染模板</a>
<ul>
<li><a href=#创建布局>创建布局</a></li>
<li><a href=#文章列表页面>文章列表页面</a></li>
<li><a href=#文章详情页>文章详情页</a></li>
<li><a href=#路由-1>路由</a></li>
</ul>
</li>
<li><a href=#后续步骤>后续步骤</a></li>
<li><a href=#教程源码>教程源码</a></li>
</ul>
</nav>
</div>
<main class="col-12 col-md-9 col-xl-8 py-md-3 pl-md-5 docs-content" role=main>
<article class=article>
<div class="alert alert-warning" role=alert>
官方文档中文版翻译工作仍在进行中，欢迎 <a href=/post/join-translation/>参与翻译</a>。
</div>
<div class=docs-article-container>
<h1>上手指南</h1>
<div class=article-style>
<p>Spiral 框架包含了大量的组件，这些组件各自承担不同的职责，彼此之间相互协同，紧密合作，从而构建出各种复杂的应用。
在本文中，我会通过一个博客应用示例，向大家演示 Rest API、ORM（对象关系映射）、migrations（数据迁移）、request validation（请求校验）、custom annotations（可选的）以及 domain interceptors（域拦截器）的使用。</p>
<blockquote>
<p>实例中对各个组件和实现方法的介绍都只停留在基础层面，如果需要对任何一个部分进行更深入的理解，请自行阅读文档相应章节。</p>
</blockquote>
<h2 id=安装>安装</h2>
<p>可以通过 composer 命令和官方提供的 <code>spiral/app</code> 包进行安装（或者说创建项目），这个包已经为大家集成了 WEB 应用开发中可能用到的大量组件：</p>
<pre><code class=language-bash>$ composer create-project spiral/app spiral-demo
$ cd spiral-demo
</code></pre>
<blockquote>
<p>如果不需要创建完整的 WEB 应用，也可以考虑通过另外一个 <code>spiral/app-cli</code> 包来创建初始项目，这个包只继承了最少的依赖项。</p>
</blockquote>
<p>当项目创建完成，且所有依赖项都成功安装之后，你可以通过下面的命令立刻启动应用服务器：</p>
<pre><code class=language-bash>$ ./spiral serve -v -d
</code></pre>
<p>这个命令启动了 Spiral 的
<a href=/docs/framework/application-server/>应用服务器</a>。该服务器同时可以用于开发和生产环境，因此开发者的开发环境与最终部署环境会非常接近。Spiral 的应用程序集成了 HTTP/2、GRPC、Queue（队列）、WebSockets 等开发应用程序所需的工具，开箱即用，无需外部代理或其它配置。</p>
<p>默认情况下，通过上面的命令启动服务器之后，可以通过 <code>http://localhost:8080</code> 访问 web 应用。初始化的项目包含了几个预设的页面，你可以直接使用它们，或者用它们作为参考。</p>
<blockquote>
<p>访问 <code>http://localhost:8080/exception.html</code> 可以看到默认的报错页面。在页面右侧可以看到默认项目已经集成的所有拦截器和中间件。根据你的实际情况，可以把不需要的关闭，让应用运行时更小更省资源。</p>
</blockquote>
<h2 id=配置>配置</h2>
<p>Spiral 应用程序通过 <code>app/config</code> 目录下的配置文件对项目进行配置。在配置文件中，你可以硬编码配置值，当然也可以而且推荐通过 <code>env</code> 和 <code>directory</code> 函数来获得所需的敏感信息。<code>spiral/app</code> 项目使用
<a href=https://github.com/vlucas/phpdotenv target=_blank rel=noopener>DotEnv 扩展</a>从项目根目录下的 <code>.env</code> 文件中读取环境变量。</p>
<blockquote>
<p>在 <code>.rr.yaml</code> 文件中可以对应用服务器及其插件的参数进行调整。</p>
</blockquote>
<p>项目的依赖项定义在 <code>composer.json</code> 文件中，并在 <code>app/src/App.php</code> 文件中作为引导程序启用。项目默认包含了大量预配置的组件。</p>
<h3 id=开发模式>开发模式</h3>
<p>在开发阶段，为了简化开发调试，可以以开发模式启动应用服务器。在开发模式下，应用服务器只是用一个工作进程，并在处理完每个请求之后重新加载代码。</p>
<pre><code class=language-bash>$ ./spiral serve -v -d -o &quot;http.workers.pool.maxJobs=1&quot; -o &quot;http.workers.pool.numWorkers=1&quot;
</code></pre>
<p>当然你也可以把参数放到一个单独的 <code>.rr.dev.yaml</code> 之类的文件中，并在是用 <code>spiral</code> 命令时通过 <code>-c</code> 参数指定该配置文件。</p>
<blockquote>
<p>请参阅
<a href=/docs/start/workers/>这篇文档</a> 以了解更多有关工作进程和应用生命周期的信息。</p>
</blockquote>
<h3 id=清理项目>清理项目</h3>
<p>在我们的演示应用中，不会用到 translation（多语言翻译）、session、cookies、CSRF 和 encryption（加密）组件。所以接下来先从引导程序中移除他们。</p>
<p>打开 <code>app/src/App.php</code> 文件，找到并删除下面列出来的代码（注释是为了方便你定位代码，不必删除相关的注释行）：</p>
<pre><code class=language-php>// Core Services
Framework\I18nBootloader::class,

// Security and validation
Framework\Security\EncrypterBootloader::class,

// HTTP extensions
Framework\Http\CookiesBootloader::class,
Framework\Http\SessionBootloader::class,
Framework\Http\CsrfBootloader::class,
Framework\Http\PaginationBootloader::class,

// Views and view translation
Framework\Views\TranslatedCacheBootloader::class,

// Application specific services and extensions
Bootloader\LocaleSelectorBootloader::class,
</code></pre>
<p>删除了相应的引导程序之后，如果你愿意的话，也可以从 <code>composer.json</code> 中删除相关的依赖项：</p>
<pre><code class=language-bash>&quot;spiral/cookies&quot;: &quot;^1.0&quot;,
&quot;spiral/csrf&quot;: &quot;^1.0&quot;,
&quot;spiral/session&quot;: &quot;^1.1&quot;,
&quot;spiral/translator&quot;: &quot;^1.2&quot;,
&quot;spiral/encrypter&quot;: &quot;^1.1&quot;,
</code></pre>
<p>最后，还可以删除掉默认项目自带的以下文件或目录：</p>
<ul>
<li><code>app/locale</code></li>
<li><code>app/src/Bootloader/LocaleSelectorBootloader.php</code></li>
<li><code>app/src/Middleware</code>.</li>
</ul>
<blockquote>
<p>提示， 现在应用程序不能工作了，因为我们刚刚删除了渲染 <code>app/views/home/dark.php</code> 所需的依赖项（国际化相关的依赖）。</p>
</blockquote>
<h3 id=数据库连接>数据库连接</h3>
<p>博客系统作为常见的数据库驱动的应用，当然需要一个可操作的数据库。数据库的配置文件默认在 <code>app/config/database.php</code> 这个位置。新创建的项目已经配置好了一个 SQLite 数据库，存放在 <code>runtime/runtime.db</code> 这个路径下。</p>
<pre><code class=language-php>// app/config/database.php

use Spiral\Database\Driver;

return [
    'default'   =&gt; 'default',
    'databases' =&gt; [
        'default' =&gt; ['driver' =&gt; 'runtime'],
    ],
    'drivers'   =&gt; [
        'runtime' =&gt; [
            'driver'     =&gt; Driver\SQLite\SQLiteDriver::class,
            'options'    =&gt; [
                'connection' =&gt; 'sqlite:' . directory('runtime') . 'runtime.db',
            ]
        ],
    ]
];
</code></pre>
<p>接下来我们配置一个 MySQL 的连接（如果你没有 MySQL 数据库，可以跳过这部分），连接 MySQL 的信息，最好不要直接硬编码到配置文件中，可以存放到项目根目录下的 <code>.env</code> 文件里（这个文件不要上传到你的代码仓库）。比如我们在 <code>.env</code> 文件中写入以下环境变量：</p>
<pre><code class=language-ini>DB_HOST=localhost
DB_NAME=name
DB_USER=username
DB_PASSWORD=password
</code></pre>
<blockquote>
<p>请根据自己的情况修改对应的值</p>
</blockquote>
<p>然后在 <code>app/config/database.php</code> 文件中，配置一个名为 <code>mysql</code> 的数据库驱动（drivers），然后把 <code>databases</code> 项下面的 <code>drivers</code> 指向新增的 MySQL 驱动：</p>
<pre><code class=language-php>return [
    'default'   =&gt; 'default',
    'databases' =&gt; [
        'default' =&gt; ['driver' =&gt; 'mysql'],
    ],
    'drivers'   =&gt; [
         'runtime' =&gt; [
            'driver'     =&gt; Driver\SQLite\SQLiteDriver::class,
            'options'    =&gt; [
                'connection' =&gt; 'sqlite:' . directory('runtime') . 'runtime.db',
            ]
        ],
        'mysql' =&gt; [
            'driver'     =&gt; Driver\MySQL\MySQLDriver::class,
            'connection' =&gt; sprintf('mysql:host=%s;dbname=%s', env('DB_HOST'), env('DB_NAME')),
            'username'   =&gt; env('DB_USER'),
            'password'   =&gt; env('DB_PASSWORD'),
        ],
    ]
];
</code></pre>
<blockquote>
<p>请注意当前 <code>default</code> 数据库指向了 <code>default</code> 连接，<code>default</code> 连接指向了 <code>mysql</code> 配置。在 Spiral 中，你可以同时配置多个数据库驱动、同时启用多个数据库连接。具体请参阅数据库相关章节的文档。</p>
</blockquote>
<p>然后通过以下命令，可以检查数据库连接是否配置正确：</p>
<pre><code class=language-bash>$ php app.php db:list
</code></pre>
<p>如果连接配置正确，你会看到类似这样的输出（注意 <code>Status</code> 应该是 &ldquo;connected&rdquo;）：</p>
<pre><code class=language-bash>+------------+-----------+---------+---------+-----------+-----------+----------------+
| Name (ID): | Database: | Driver: | Prefix: | Status:   | Tables:   | Count Records: |
+------------+-----------+---------+---------+-----------+-----------+----------------+
| default    | spiral    | MySQL   | ---     | connected | no tables | no records     |
+------------+-----------+---------+---------+-----------+-----------+----------------+
</code></pre>
<blockquote>
<p>有关数据库连接的更多信息，请查看
<a href=/docs/database/configuration/>数据库配置文档</a>.</p>
</blockquote>
<h3 id=假数据>假数据</h3>
<p>在开发阶段，通常我们需要一个假数据。<code>fzaninotto/faker</code> 这个库提供了强大的假数据生成功能。</p>
<p>首先在项目中安装这个库作为依赖项：</p>
<pre><code class=language-bash>$ composer require fzaninotto/faker
</code></pre>
<p>为了生成数据，需要创建一个 <code>Faker\Generator</code> 实例，在 Spiral 中我们不必每次用到它的时候都去生成一次新的实例，可以在 <code>app/src/Bootloader</code> 目录下创建一个引导程序，该引导程序在每次我们需要这个类的时候就提供一个单例对象。这里使用的是设计模式中的工厂模式。</p>
<pre><code class=language-php>namespace App\Bootloader;

use Faker\Factory;
use Faker\Generator;
use Spiral\Boot\Bootloader\Bootloader;

class FakerBootloader extends Bootloader
{
    protected const SINGLETONS = [
        Generator::class =&gt; [self::class, 'fakerGenerator']
    ];

    private function fakerGenerator(): Generator
    {
        return Factory::create(Factory::DEFAULT_LOCALE);
    }
}
</code></pre>
<p>创建好引导程序之后，要把它加到 <code>app/src/App.php</code> 的 <code>LOAD</code> 或者 <code>APP</code> 常量数组中才能启用该引导程序：</p>
<pre><code class=language-diff>--- a/app/src/App.php
+++ b/app/src/App.php
@@ -85,5 +85,6 @@ class App extends Kernel

         // fast code prototyping
         Prototype\PrototypeBootloader::class,
+        Bootloader\FakerBootloader::class,
     ];
 }
</code></pre>
<blockquote>
<p>在 <code>fakerGenerator</code> 这个方法上，你可以添加依赖项作为参数，Spiral 容器会自动注入依赖项。</p>
</blockquote>
<p>然后我们修改一下 <code>app/src/Controllers/HomeController.php</code> 中的代码，通过 <code>http://localhost:8080/</code> 来看一下假数据生成工具是否正常工作了：</p>
<pre><code class=language-php>namespace App\Controller;

use Faker\Generator;

class HomeController
{
    public function index(Generator $generator)
    {
        return $generator-&gt;sentence(128);
    }
}
</code></pre>
<p>一切正常的话，你会看到一大段随机生成的文字。</p>
<blockquote>
<p>要深入了解引导程序，请阅读
<a href=/docs/framework/bootloaders/>这篇文档</a>。</p>
</blockquote>
<h3 id=路由>路由</h3>
<p>默认情况下，路由规则的定义在 <code>app/src/Bootloader/RoutesBootloader.php</code> 文件中。对于配置路由而言，你有很多选择。可以把路由指向控制器、控制器方法、控制器组；可以指定默认的匹配参数……</p>
<p>作为实例，我们先创建一个简单的路由，把所有 URL 都指向 <code>App\Controller\HomeController</code>:</p>
<pre><code class=language-php>namespace App\Bootloader;

use App\Controller\HomeController;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Router\Route;
use Spiral\Router\RouterInterface;
use Spiral\Router\Target\Controller;

class RoutesBootloader extends Bootloader
{
    public function boot(RouterInterface $router): void
    {
        $route = new Route('/[&lt;action&gt;[/&lt;id&gt;]]', new Controller(HomeController::class));
        $route = $route-&gt;withDefaults(['action' =&gt; 'index']);

        $router-&gt;setRoute('home', $route);
    }
}
</code></pre>
<p>按照上面给出的配置，<code>action</code> 和 <code>id</code> 都是 URL 中的可选部分（使用了 <code>[]</code>），默认的 action 值是 <code>index</code>。所以如果访问 <code>http://localhost:8080/</code> 或者 <code>http://localhost:8080/index</code>，都会执行 <code>HomeController::index</code> 方法。</p>
<p>在控制器方法中可以采用方法注入的方式，通过路由参数的名称来访问它们，比如在 <code>HomeController</code> 中增加下面的方法：</p>
<pre><code class=language-php>public function open(string $id)
{
    dump($id);
}
</code></pre>
<p>然后可以通过 <code>http://localhost:8080/open/123</code> 来调用这个方法，<code>id</code> 参数会被自动注入。</p>
<blockquote>
<p>有关路由配置的更多信息，请参阅
<a href=/docs/http/routing/>路由文档</a>。</p>
</blockquote>
<h3 id=注解式路由>注解式路由</h3>
<p>Spiral 框架默认没有提供开箱即用的注解式路由配置。但是可以通过已有的组件进行简单地配置来
<a href=/docs/cookbook/annotated-routes/>实现它</a>。</p>
<h3 id=注解>注解</h3>
<p>我们首先要创建一个简单的注解，稍后可以把它应用到公共控制器方法上：</p>
<pre><code class=language-php>namespace App\Annotation;

use Doctrine\Common\Annotations\Annotation;

/**
 * @Annotation()
 * @Annotation\Target({&quot;METHOD&quot;})
 * @Annotation\Attributes({
 *      @Annotation\Attribute(&quot;action&quot;, type=&quot;string&quot;, required=true),
 *      @Annotation\Attribute(&quot;verbs&quot;, type=&quot;array&quot;),
 * })
 */
class Route
{
    /** @var string */
    public $action;

    /** @var string[]|null */
    public $verbs;
}
</code></pre>
<blockquote>
<p>WEB 应用框架 <code>apiral/app</code> 默认已经引入并启用了注解组件 <code>spiral/annotations</code>（作为 <code>spiral/prototype</code> 的依赖项）。</p>
</blockquote>
<h4 id=引导程序>引导程序</h4>
<p>修改 <code>RoutesBootloader</code>，让它可以把注解转换为路由。可以使用 <code>Spiral\Annotations\AnnotationLocator</code> 这个类来查找代码中可用的路由注解。</p>
<pre><code class=language-php>namespace App\Bootloader;

use Spiral\Annotations\AnnotationLocator;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Router\Route;
use Spiral\Router\RouterInterface;
use Spiral\Router\Target\Action;

class RoutesBootloader extends Bootloader
{
    public function boot(AnnotationLocator $annotationLocator, RouterInterface $router): void
    {
        $methods = $annotationLocator-&gt;findMethods(\App\Annotation\Route::class);

        foreach ($methods as $method) {
            $name = sprintf(
                &quot;%s.%s&quot;,
                $method-&gt;getClass()-&gt;getShortName(),
                $method-&gt;getMethod()-&gt;getShortName()
            );

            $route = new Route(
                $method-&gt;getAnnotation()-&gt;action,
                new Action(
                    $method-&gt;getClass()-&gt;getName(),
                    $method-&gt;getMethod()-&gt;getName()
                )
            );

            $route = $route-&gt;withVerbs(...(array)$method-&gt;getAnnotation()-&gt;verbs);

            $router-&gt;setRoute($name, $route);
        }
    }
}
</code></pre>
<h4 id=控制器>控制器</h4>
<p>创建好了注解，并在引导程序中实现了注解转换为路由的逻辑之后，就可以在控制器中用注解来定义路由规则了：</p>
<pre><code class=language-php>namespace App\Controller;

use App\Annotation\Route;

class HomeController
{
    /**
     * @Route(action=&quot;/&quot;, verbs={&quot;GET&quot;})
     */
    public function index()
    {
        return 'hello world';
    }

    /**
     * @Route(action=&quot;/open/&lt;id&gt;&quot;, verbs={&quot;GET&quot;})
     */
    public function open(string $id)
    {
        dump($id);
    }
}
</code></pre>
<p>可以在命令行下执行以下命令列出所有已经登记的路由：</p>
<pre><code class=language-bash>$ php app.php route:list
</code></pre>
<p>上面示例中的控制器注解，执行这个命令后的输出类似这样：</p>
<pre><code class=language-bash>+--------+------------+----------------------------------+
| Verbs: | Pattern:   | Target:                          |
+--------+------------+----------------------------------+
| GET    | /          | Controller\HomeController-&gt;index |
| GET    | /open/&lt;id&gt; | Controller\HomeController-&gt;open  |
+--------+------------+----------------------------------+
</code></pre>
<blockquote>
<p>还可以使用更多的路由参数来配置中间件、通用前缀等。</p>
</blockquote>
<p>在接下来的示例中，为了简单起见，我们就一直使用注解路由来演示了。</p>
<blockquote>
<p>如果你在调试过程中觉得日志不够详细，可以在 <code>App</code> 中禁用 <code>ErrorHandleRootLoader</code> 来查看完整的错误日志。</p>
</blockquote>
<h3 id=领域内核>领域内核</h3>
<p>连接自定义的控制器拦截器（领域内核）可以用附加功能来丰富应用的领域层。比如改变应用的默认行为、把路由参数自动解析为 Cycle 实体，进行请求参数的过滤和验证，或者实现 @Guard 注解等。</p>
<p>首先创建一个引导程序 <code>AppBootloader</code> 来注入拦截器：</p>
<pre><code class=language-php>namespace App\Bootloader;

use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\Domain;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class =&gt; [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        Domain\FilterInterceptor::class,
        Domain\CycleInterceptor::class,
        Domain\GuardInterceptor::class,
    ];
}
</code></pre>
<p>上面的代码通过 <code>AppBootloader</code> 引导程序在应用中启用了一些拦截器。记得在 <code>App.php</code> 的 <code>LOAD</code> 或者 <code>APP</code>（推荐）中添加该引导程序。</p>
<pre><code class=language-php>    protected const APP = [
        Bootloader\RoutesBootloader::class,

        // fast code prototyping
        Prototype\PrototypeBootloader::class,
        Bootloader\FakerBootloader::class,
        Bootloader\AppBootloader::class,
    ];
</code></pre>
<blockquote>
<p>要深入了解领域内核，可以查询领域内核的
<a href=/docs/cookbook/domain-core/>详细文档</a>。</p>
</blockquote>
<h2 id=数据库脚手架>数据库脚手架</h2>
<p>Spiral 支持通过数据库迁移文件来配置数据库的结构。执行以下命令可以初始化数据库迁移记录表：</p>
<pre><code class=language-bash>$ php app.php migrate:init
</code></pre>
<p>之后可以用以下命令来观察数据库迁移记录表的的结构：</p>
<pre><code class=language-bash>$ php app.php db:list
$ php app.php db:table migrations
</code></pre>
<p>你可以手工创建数据库迁移文件，或者让 Cycle ORM 帮你生成。</p>
<blockquote>
<p>请参阅
<a href=/docs/database/migrations/>数据库迁移文档</a>，使用
<a href=/docs/basics/scaffolding/>脚手架</a>组件来辅助人工创建迁移文件。</p>
</blockquote>
<h3 id=定义-orm-实体>定义 ORM 实体</h3>
<p>我们的示例项目以及所有基于 <code>spiral/app</code> 创建的项目都自带了
<a href=https://cycle-orm.dev target=_blank rel=noopener>Cycle ORM</a> 组件。默认配置下你直接就可以使用注解来配置关系对象实体。</p>
<p>我们先创建 <code>Post</code>, <code>User</code> 和 <code>Comment</code> 三个实体以及它们之间的关系。这里使用脚手架命令来创建：</p>
<pre><code class=language-bash>$ php app.php create:entity post -f id:primary -f title:string -f content:text -e
$ php app.php create:entity user -f id:primary -f name:string -e
$ php app.php create:entity comment -f id:primary -f message:string
</code></pre>
<blockquote>
<p>Observe the classes generated in <code>app/src/Database</code> and <code>app/src/Repository</code>.
执行命令后请观察项目下 <code>app/src/Database</code> 和 <code>app/src/Repository</code> 目录，相关的类文件已经自动创建。</p>
</blockquote>
<p>Post:</p>
<pre><code class=language-php>namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;

/**
 * @Cycle\Entity(repository = &quot;App\Repository\PostRepository&quot;)
 */
class Post
{
    /**
     * @Cycle\Column(type = &quot;primary&quot;)
     */
    public $id;

    /**
     * @Cycle\Column(type = &quot;string&quot;)
     */
    public $title;

    /**
     * @Cycle\Column(type = &quot;text&quot;)
     */
    public $content;
}

</code></pre>
<p>User:</p>
<pre><code class=language-php>namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;

/**
 * @Cycle\Entity(repository = &quot;App\Repository\UserRepository&quot;)
 */
class User
{
    /**
     * @Cycle\Column(type = &quot;primary&quot;)
     */
    public $id;

    /**
     * @Cycle\Column(type = &quot;string&quot;)
     */
    public $name;
}
</code></pre>
<p>Comment:</p>
<pre><code class=language-php>namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;

/**
 * @Cycle\Entity()
 */
class Comment
{
    /**
     * @Cycle\Column(type = &quot;primary&quot;)
     */
    public $id;

    /**
     * @Cycle\Column(type = &quot;string&quot;)
     */
    public $message;
}
</code></pre>
<p>如果你不喜欢默认配置下的目录映射关系以及自动生成的文件的头部注释等，可以通过
<a href=/docs/basics/scaffolding/>脚手架配置</a>来调整。</p>
<blockquote>
<p>关于 Cycle 的更多信息，请参考
<a href=/docs/cycle/configuration/>Cycle 文档</a>。使用
<a href=https://cycle-orm.dev/docs/advanced-timestamp target=_blank rel=noopener>自定义映射</a>可以配置自动时间戳。</p>
</blockquote>
<h3 id=生成数据库迁移文件>生成数据库迁移文件</h3>
<p>通过 cycle 的脚手架命令，可以自动把实体的最新修改自动生成为数据库迁移文件：</p>
<pre><code class=language-bash>$ php app.php cycle:migrate -v
</code></pre>
<p>生成的迁移文件存放在 <code>app/migrations/</code> 目录下，可以通过命令来执行数据库迁移文件，更新已连接的数据库结构：</p>
<pre><code class=language-bash>$ php app.php migrate -vv
</code></pre>
<p>然后通过 <code>db:list</code> 命令就可以看到新生成的数据表。</p>
<h3 id=创建实体关系>创建实体关系</h3>
<p>通过
<a href=https://cycle-orm.dev/docs/annotated-relations target=_blank rel=noopener>关系注解</a> 来定义实体之间的关系。配置 Post 和 Comment 属于 User、Post 拥有多个 Comment。</p>
<p>Post:</p>
<pre><code class=language-php>namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @Cycle\Entity(repository = &quot;App\Repository\PostRepository&quot;)
 */
class Post
{
    /**
     * @Cycle\Column(type = &quot;primary&quot;)
     */
    public $id;

    /**
     * @Cycle\Column(type = &quot;string&quot;)
     */
    public $title;

    /**
     * @Cycle\Column(type = &quot;text&quot;)
     */
    public $content;

    /**
     * @Cycle\Relation\BelongsTo(target = &quot;User&quot;, nullable = false)
     * @var User
     */
    public $author;

    /**
     * @Cycle\Relation\HasMany(target = &quot;Comment&quot;)
     * @var ArrayCollection|Comment[]
     */
    public $comments;

    public function __construct()
    {
        $this-&gt;comments = new ArrayCollection();
    }
}
</code></pre>
<p>Comment:</p>
<pre><code class=language-php>namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;

/**
 * @Cycle\Entity()
 */
class Comment
{
    /**
     * @Cycle\Column(type = &quot;primary&quot;)
     */
    public $id;

    /**
     * @Cycle\Column(type = &quot;string&quot;)
     */
    public $message;

    /**
     * @Cycle\Relation\BelongsTo(target = &quot;User&quot;, nullable = false)
     * @var User
     */
    public $author;

    /**
     * @Cycle\Relation\BelongsTo(target = &quot;Post&quot;, nullable = false)
     * @var Post
     */
    public $post;
}
</code></pre>
<p>实体发生变更之后，再次执行 <code>cycle:migrate</code> 生成迁移文件，<code>migrate</code> 执行迁移：</p>
<pre><code class=language-bash>$ php app.php cycle:migrate -v
$ php app.php migrate -vv
</code></pre>
<blockquote>
<p>提示：你可以通过一条命令生成迁移文件并执行文件：<code>php app.php cycle:migrate -r</code>.</p>
</blockquote>
<p>我们可以通过 <code>db:table</code> 命令来看一下自动生成的外键：</p>
<pre><code class=language-bash>$ php app.php db:table comments
</code></pre>
<blockquote>
<p>切记：修改了实体之后，一定要记得执行 <code>php app.php cycle:migrate</code>。</p>
</blockquote>
<h2 id=服务>服务</h2>
<p>我们把业务逻辑单独封装到服务层，与数据层隔离开。先在 <code>app/src/Service</code> 目录下创建 <code>PostService</code> 服务类。在服务类中，需要用到实现 <code>Cycle\ORM\TransactionInterface</code> 接口的实例来进行 post 的数据持久化。</p>
<pre><code class=language-php>namespace App\Service;

use App\Database\Post;
use App\Database\User;
use Cycle\ORM\TransactionInterface;

class PostService
{
    private $tr;

    public function __construct(TransactionInterface $tr)
    {
        $this-&gt;tr = $tr;
    }

    public function createPost(User $user, string $title, string $content): Post
    {
        $post = new Post();
        $post-&gt;author = $user;
        $post-&gt;title = $title;
        $post-&gt;content = $content;

        $this-&gt;tr-&gt;persist($post);
        $this-&gt;tr-&gt;run();

        return $post;
    }
}
</code></pre>
<blockquote>
<p>你可以在 <code>run</code> 方法后重用事务。</p>
</blockquote>
<h3 id=原型开发辅助>原型开发辅助</h3>
<p>Spiral 框架强大功能的其中一项就是它的
<a href=/docs/basics/prototype/>原型开发辅助</a>。给 <code>PostService</code> 类加一个原型注解，指定 <code>postService</code> 指向 <code>PostService</code>:</p>
<pre><code class=language-php>namespace App\Service;

use App\Database\Post;
use App\Database\User;
use Cycle\ORM\TransactionInterface;
use Spiral\Prototype\Annotation\Prototyped;

/**
 * @Prototyped(property=&quot;postService&quot;)
 */
class PostService
{
    // ...
}
</code></pre>
<p>同样的给 <code>app/src/Repository/UserRepository.php</code> 中的 <code>UserRepository</code> 也加一个原型注解，指定 <code>users</code> 指向 <code>UserRepository</code>:</p>
<pre><code class=language-php>
</code></pre>
<p>更新了原型开发辅助相关的配置后，要执行配置命令来收集所有可用的原型类：</p>
<pre><code class=language-bash>$ php app.php configure
</code></pre>
<blockquote>
<p>要获得 IDE 智能提示，需要使用支持的 IDE，比如 Jetbrains PHPStorm.</p>
</blockquote>
<p>经过上述配置和操作，现在通过引入 <code>PrototypeTrait</code>，就可以在类中直接访问到 <code>PostService</code> 对象了（见下面的示例）。</p>
<h2 id=控制台命令>控制台命令</h2>
<p>经过上面的步骤，我们已经创建了数据实体、实体关系、数据访问仓库、服务等，但是还没有可用的假数据呢。所以，接下来我们先创建三个命令，用来生成假数据：</p>
<pre><code class=language-bash>$ php app.php create:command seed/user seed:user
$ php app.php create:command seed/post seed:post
$ php app.php create:command seed/comment seed:comment
</code></pre>
<p>生成的命令类，文件都在 <code>app/src/Command/Seed</code> 目录下。</p>
<h3 id=usercommand>UserCommand</h3>
<p>在生成的 &ldquo;UserCommand&rdquo; 类中，可以在 <code>perform</code> 方法上使用方法注入需要的对象，比如 <code>Faker\Generator</code>，然后用它来生成假数据：</p>
<pre><code class=language-php>// app/src/Command/Seed/UserCommand.php
namespace App\Command\Seed;

use App\Database\User;
use Cycle\ORM\TransactionInterface;
use Faker\Generator;
use Spiral\Console\Command;

class UserCommand extends Command
{
    protected const NAME = 'seed:user';

    protected function perform(TransactionInterface $tr, Generator $faker): void
    {
        for ($i = 0; $i &lt; 100; $i++) {
            $user = new User();
            $user-&gt;name = $faker-&gt;name;

            $tr-&gt;persist($user);
        }

        $tr-&gt;run();
    }
}
</code></pre>
<p>然后执行创建好的命令：</p>
<pre><code class=language-bash>$ php app.php seed:user
</code></pre>
<h3 id=postcommand>PostCommand</h3>
<p>同样地，使用 <code>PostCommand</code> 来生成假的文章数据，不过，在这个命令里，我们会使用原型开发辅助提供的属性来代替方法注入。通过原型开发辅助，可以大大提升我们的开发效率。请注意下面的示例代码中，我们直接通过类属性来访问 <code>postService</code> 和 <code>users</code>（对应 <code>UserRepository</code>）。</p>
<blockquote>
<p>如果你的 IDE 没有智能提示仓库类或者其它服务，请执行 <code>php app.php configure</code>.</p>
</blockquote>
<pre><code class=language-php>// app/src/Command/Seed/PostCommand.php
namespace App\Command\Seed;

use Faker\Generator;
use Spiral\Console\Command;
use Spiral\Prototype\Traits\PrototypeTrait;

class PostCommand extends Command
{
    use PrototypeTrait;

    protected const NAME = 'seed:post';

    protected function perform(Generator $faker): void
    {
        $users = $this-&gt;users-&gt;findAll();

        for ($i = 0; $i &lt; 1000; $i++) {
            $user = $users[array_rand($users)];

            $post = $this-&gt;postService-&gt;createPost(
                $user,
                $faker-&gt;sentence(12),
                $faker-&gt;text(900)
            );

            $this-&gt;sprintf(&quot;New post: &lt;info&gt;%s&lt;/info&gt;\n&quot;, $post-&gt;title);
        }
    }
}
</code></pre>
<p>然后在控制台执行 <code>seed:post</code> 命令，加上 <code>-vv</code> 参数可以观察到 SQL 查询：</p>
<pre><code class=language-bash>$ php app.php seed:post -vv
</code></pre>
<p>开发完成以后，通过脚手架命令可以自动从源代码中移除原型开发辅助：</p>
<pre><code class=language-bash>$ php app.php prototype:inject -r
</code></pre>
<p>这条命令会自动修改使用了 <code>PrototypeTrait</code> 的类，修改后的代码如下：</p>
<pre><code class=language-php>namespace App\Command\Seed;

use App\Repository\UserRepository;
use App\Service\PostService;
use Faker\Generator;
use Spiral\Console\Command;

class PostCommand extends Command
{
    protected const NAME = 'seed:post';

    /** @var UserRepository */
    private $users;

    /** @var PostService */
    private $postService;

    /**
     * @param UserRepository $users2
     * @param PostService    $postService
     * @param string|null    $name
     */
    public function __construct(UserRepository $users2, PostService $postService, ?string $name = null)
    {
        parent::__construct($name);
        $this-&gt;postService = $postService;
        $this-&gt;users = $users2;
    }

    protected function perform(Generator $faker): void
    {
        $users = $this-&gt;users-&gt;findAll();

        for ($i = 0; $i &lt; 1000; $i++) {
            $user = $users[array_rand($users)];

            $post = $this-&gt;postService-&gt;createPost(
                $user,
                $faker-&gt;sentence(12),
                $faker-&gt;text(900)
            );

            $this-&gt;sprintf(&quot;New post: &lt;info&gt;%s&lt;/info&gt;\n&quot;, $post-&gt;title);
        }
    }
}
</code></pre>
<blockquote>
<p>在开发阶段，你可以在任意代码中使用原型开发辅助，然后在最终上线前通过命令批量移除开发辅助，还可以从项目中删除掉整个扩展组件。</p>
</blockquote>
<h3 id=commentcommand>CommentCommand</h3>
<p>生成 comment 假数据时，需要随机指定 user 和 post 来生成关联的评论。可以通过方法注入来获得需要的实例对象。</p>
<pre><code class=language-php>namespace App\Command\Seed;

use App\Database\Comment;
use App\Repository\PostRepository;
use App\Repository\UserRepository;
use Cycle\ORM\TransactionInterface;
use Faker\Generator;
use Spiral\Console\Command;

class CommentCommand extends Command
{
    protected const NAME = 'seed:comment';

    protected function perform(
        Generator $faker,
        TransactionInterface $tr,
        UserRepository $userRepository,
        PostRepository $postRepository
    ): void {
        $users = $userRepository-&gt;findAll();
        $posts = $postRepository-&gt;findAll();

        for ($i = 0; $i &lt; 1000; $i++) {
            $user = $users[array_rand($users)];
            $post = $posts[array_rand($posts)];

            $comment = new Comment();
            $comment-&gt;author = $user;
            $comment-&gt;post = $post;
            $comment-&gt;message = $faker-&gt;sentence(12);

            $tr-&gt;persist($comment);
            $tr-&gt;run();
        }
    }
}
</code></pre>
<p>同样地，执行 <code>seed:comment</code> 命令生成评论数据：</p>
<pre><code class=language-bash>$ php app.php seed:comment -vv
</code></pre>
<h2 id=控制器-1>控制器</h2>
<p>作为 Restful API 应用，我们需要创建一系列 REST 端点来提供访问数据的 API。首先创建一个简单的控制器，<code>App\Controller\PostController</code>, 可以通过脚手架命令来快速创建：</p>
<pre><code class=language-bash>$ php .\app.php create:controller post -a test -a get -p
</code></pre>
<blockquote>
<p>提示： <code>-a</code> 选项可以预创建控制器方法，<code>-p</code> 选项可以预加载原型开发辅助扩展。</p>
</blockquote>
<p>生成的代码如下：</p>
<pre><code class=language-php>namespace App\Controller;

use Spiral\Prototype\Traits\PrototypeTrait;

class PostController
{
    use PrototypeTrait;

    public function test()
    {
    }

    public function get()
    {
    }
}
</code></pre>
<h3 id=测试方法>测试方法</h3>
<p>在 Spiral 的控制器方法中，你可以返回不同类型的数据。以下这些类型的数据都是有效的：</p>
<ul>
<li>string（字符串）</li>
<li>PSR-7 Response（PSR-7 规范定义的 Response 对象）</li>
<li>array（数组会自动作为 JSON 响应给用户）</li>
<li>JsonSerializable（可以序列化为 JSON 字符串的对象）</li>
</ul>
<blockquote>
<p>除了上述默认支持的类型外，还可以通过自定义的
<a href=/docs/cookbook/domain-core/>领域核心</a>执行自己定义的响应格式转换。在使用了原型开发辅助之后，也可以借由 <code>$this->response</code> 辅助属性把数据写入到标准的 PSR-7 响应对象。</p>
</blockquote>
<p>为了演示返回数组的实现，我们在返回数据中加了一个 <code>status</code> 键，代表响应状态。</p>
<pre><code class=language-php>/**
 * @Route(action=&quot;/api/test/&lt;id&gt;&quot;, verbs={&quot;GET&quot;})
 * @param string $id
 * @return array
 */
public function test(string $id)
{
    return [
        'status' =&gt; 200,
        'data'   =&gt; [
            'id' =&gt; $id
        ]
    ];
}
</code></pre>
<p>在浏览器中打开 <code>http://localhost:8080/api/test/123</code> 可以看到输出的 JSON 数据。</p>
<p>上面这种方式，我们无法控制 HTTP 响应的状态码，响应的数据里的 <code>status</code> 只是 JSON 数据里的响应状态，而 HTTP 响应状态码始终是 200. 因此我们还有另外的方法，比如使用 <code>ResponseWrapper</code> 辅助类：</p>
<pre><code class=language-php>use Psr\Http\Message\ResponseInterface;

// ...

/**
 * @Route(action=&quot;/api/test/&lt;id&gt;&quot;, verbs={&quot;GET&quot;})
 * @param string $id
 * @return ResponseInterface
 */
public function test(string $id): ResponseInterface
{
    return $this-&gt;response-&gt;json(
        [
            'data' =&gt; [
                'id' =&gt; $id
            ]
        ],
        200
    );
}
</code></pre>
<p>这里向 <code>json</code> 函数传入的第二个参数值 <code>200</code> 就指定了本次响应的状态码。</p>
<blockquote>
<p>这个 test 方法仅仅作为这里的演示，后续不再需要。</p>
</blockquote>
<h3 id=获取文章数据>获取文章数据</h3>
<p>要从数据库里查询 post 数据，需要 <code>PostRepository</code>，可以在控制器的构造函数、<code>get</code> 方法中通过方法注入来获得它的实例，也可以通过原型开发辅助提供的 <code>posts</code> 缩写（前文有相关介绍）。而需要用户提供的文章 <code>id</code>，可以通过路由参数访问到：</p>
<pre><code class=language-php>namespace App\Controller;

use App\Annotation\Route;
use App\Database\Post;
use Spiral\Http\Exception\ClientException\NotFoundException;
use Spiral\Prototype\Traits\PrototypeTrait;

class PostController
{
    use PrototypeTrait;

    /**
     * @Route(action=&quot;/api/post/&lt;id:\d+&gt;&quot;, verbs={&quot;GET&quot;})
     * @param string $id
     * @return array
     */
    public function get(string $id)
    {
        /** @var Post $post */
        $post = $this-&gt;posts-&gt;findByPK($id);
        if ($post === null) {
            throw new NotFoundException(&quot;post not found&quot;);
        }

        return [
            'post' =&gt; [
                'id'      =&gt; $post-&gt;id,
                'author'  =&gt; [
                    'id'   =&gt; $post-&gt;author-&gt;id,
                    'name' =&gt; $post-&gt;author-&gt;name
                ],
                'title'   =&gt; $post-&gt;title,
                'content' =&gt; $post-&gt;content,
            ]
        ];
    }
}
</code></pre>
<p>由于前面我们有配置领域核心，启用了 <code>CycleInterceptor</code> 拦截器，因此上面的方法也可以进一步简化，直接在方法注入中依赖 <code>Post</code>，<code>CycleInterceptor</code> 会用 <code>id</code> 进行查询并将对应的 post 注入到我们的方法中：</p>
<pre><code class=language-php>namespace App\Controller;

use App\Annotation\Route;
use App\Database\Post;
use Spiral\Prototype\Traits\PrototypeTrait;

class PostController
{
    use PrototypeTrait;

    /**
     * @Route(action=&quot;/api/post/&lt;post:\d+&gt;&quot;, verbs={&quot;GET&quot;})
     * @param Post $post
     * @return array
     */
    public function get(Post $post)
    {
        return [
            'post' =&gt; [
                'id'      =&gt; $post-&gt;id,
                'author'  =&gt; [
                    'id'   =&gt; $post-&gt;author-&gt;id,
                    'name' =&gt; $post-&gt;author-&gt;name
                ],
                'title'   =&gt; $post-&gt;title,
                'content' =&gt; $post-&gt;content,
            ]
        ];
    }
}
</code></pre>
<blockquote>
<p>实际开发中可以考虑使用视图对象把响应的数据映射到 <code>JsonSerializable</code> 形式。</p>
</blockquote>
<h3 id=post-视图映射>Post 视图映射</h3>
<p>要把数据对象映射到 JSON 格式，可以使用已有的解决方案（例如 <code>jms/serializer</code>），或者编写自己的映射实现。下面我们演示一下如何创建一个视图对象把 post 数据转换为 JSON 格式，别忘了之前的知识：通过注释中的 Prototyped 注解可以简化我们的开发。</p>
<pre><code class=language-php>namespace App\View;

use App\Database\Post;
use Psr\Http\Message\ResponseInterface;
use Spiral\Core\Container\SingletonInterface;
use Spiral\Prototype\Annotation\Prototyped;
use Spiral\Prototype\Traits\PrototypeTrait;

/**
 * @Prototyped(property=&quot;postView&quot;)
 */
class PostView implements SingletonInterface
{
    use PrototypeTrait;

    public function map(Post $post): array
    {
        return [
            'post' =&gt; [
                'id'      =&gt; $post-&gt;id,
                'author'  =&gt; [
                    'id'   =&gt; $post-&gt;author-&gt;id,
                    'name' =&gt; $post-&gt;author-&gt;name
                ],
                'title'   =&gt; $post-&gt;title,
                'content' =&gt; $post-&gt;content,
            ]
        ];
    }

    public function json(Post $post): ResponseInterface
    {
        return $this-&gt;response-&gt;json($this-&gt;map($post), 200);
    }
}
</code></pre>
<blockquote>
<p>执行 <code>php app.php configure</code> 命令注册原型开发辅助类，并生成 IDE 代码提示。</p>
</blockquote>
<p>然后修改控制器的代码使用刚才创建的 <code>PostView</code>:</p>
<pre><code class=language-php>namespace App\Controller;

use App\Annotation\Route;
use App\Database\Post;
use Psr\Http\Message\ResponseInterface;
use Spiral\Prototype\Traits\PrototypeTrait;

class PostController
{
    use PrototypeTrait;

    /**
     * @Route(action=&quot;/api/post/&lt;post:\d+&gt;&quot;, verbs={&quot;GET&quot;})
     * @param Post $post
     * @return ResponseInterface
     */
    public function get(Post $post): ResponseInterface
    {
        return $this-&gt;postView-&gt;json($post);
    }
}
</code></pre>
<blockquote>
<p>你可以通过浏览器来验证，这里对代码的重构并没有改变 API 的行为。</p>
</blockquote>
<h3 id=获取文章列表>获取文章列表</h3>
<p>我们可以直接通过实体仓库（repository）来加载多个文章。在加载多个文章时，可以考虑同时加载可用的文章（post）和它们的作者（post）以减少查询数量。</p>
<p>先在 <code>PostRepository</code> 中创建一个 <code>findAllWithAuthors</code> 方法：</p>
<pre><code class=language-php>namespace App\Repository;

use Cycle\ORM\Select;
use Cycle\ORM\Select\Repository;

class PostRepository extends Repository
{
    public function findAllWithAuthor(): Select
    {
        return $this-&gt;select()-&gt;load('author');
    }
}
</code></pre>
<p>然后在 <code>PostController</code> 中创建一个 <code>list</code> 方法来调用它：</p>
<pre><code class=language-php>/**
     * @Route(action=&quot;/api/post&quot;, verbs={&quot;GET&quot;})
     * @return array
     */
    public function list(): array
    {
        $posts = $this-&gt;posts-&gt;findAllWithAuthor();

        return [
            'posts' =&gt; array_map([$this-&gt;postView, 'map'], $posts-&gt;fetchAll())
        ];
    }
</code></pre>
<blockquote>
<p>你可以访问 <code>http://localhost:8080/api/post</code> 查看包含所有文章的 JSON 数据。</p>
</blockquote>
<h3 id=数据网格data-grid>数据网格（Data Grid）</h3>
<p>上面的实现方案有个很大的问题，因为你必须手动对结果进行分页、筛选和排序操作。</p>
<p>不过不用担心，Spiral 提供了
<a href=/docs/component/data-grid/>数据网格组件（Data Grid Component）</a>来提供数据格式化的操作：</p>
<pre><code class=language-bash>$ composer require spiral/data-grid-bridge
</code></pre>
<p>在应用中激活 <code>Spiral\DataGrid\Bootloader\GridBootloader</code> 引导程序（参见前文介绍）。</p>
<p>要使用数据网格，首先要指定数据格式，创建 <code>App\View\PostGrid</code> 类：</p>
<pre><code class=language-php>namespace App\View;

use Spiral\DataGrid\GridSchema;
use Spiral\DataGrid\Specification\Filter\Equals;
use Spiral\DataGrid\Specification\Pagination\PagePaginator;
use Spiral\DataGrid\Specification\Sorter\Sorter;
use Spiral\DataGrid\Specification\Value\IntValue;
use Spiral\Prototype\Annotation\Prototyped;

/**
 * @Prototyped(property=&quot;postGrid&quot;)
 */
class PostGrid extends GridSchema
{
    public function __construct()
    {
        $this-&gt;addFilter('author', new Equals('author.id', new IntValue()));

        $this-&gt;addSorter('id', new Sorter('id'));
        $this-&gt;addSorter('author', new Sorter('author.id'));

        // default limit, available limits
        $this-&gt;setPaginator(new PagePaginator(10, [10, 20, 50]));
    }
}
</code></pre>
<blockquote>
<p>在上面的示例中，我们为数据网格定义了一个筛选字段、两个排序字段，也指定了分页的实现和分页限制。</p>
</blockquote>
<p>然后通过 <code>Spiral\DataGrid\GridFactory</code> 把引导程序与方法连接起来：</p>
<pre><code class=language-php>/**
 * @Route(action=&quot;/api/post&quot;, verbs={&quot;GET&quot;})
 * @param GridFactory $grids
 * @return array
 */
public function list(GridFactory $grids): array
{
    $grid = $grids-&gt;create($this-&gt;posts-&gt;findAllWithAuthor(), $this-&gt;postGrid);

    return [
        'posts' =&gt; array_map(
            [$this-&gt;postView, 'map'],
            iterator_to_array($grid-&gt;getIterator())
        )
    ];
}
</code></pre>
<blockquote>
<p>如果刚才还没有做的话，别忘了每次添加 prototyped 类之后都要执行 <code>php app.php configure</code></p>
</blockquote>
<p>数据网格是一个扩展性极强的组件，有大量可以个定制化的选项。默认配置下，数据网格组件从用户请求的查询字符串和请求数据中读取需要的参数值。</p>
<table>
<thead>
<tr>
<th>网址</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>http://localhost:8080/api/post?paginate[page]=2</code></td>
<td>返回第二页数据，每页显示 10 条（上面指定的默认值）</td>
</tr>
<tr>
<td><code>http://localhost:8080/api/post?paginate[page]=2&paginate[limit]=20</code></td>
<td>返回第二页数据，每页显示 20 条</td>
</tr>
<tr>
<td><code>http://localhost:8080/api/post?sort[id]=desc</code></td>
<td>按照 post->id 倒序排列</td>
</tr>
<tr>
<td><code>http://localhost:8080/api/post?sort[author]=asc</code></td>
<td>按照 post->author->id 正序排列</td>
</tr>
<tr>
<td><code>http://localhost:8080/api/post?filter[author]=1</code></td>
<td>只返回指定 author->id 相关的 post</td>
</tr>
</tbody>
</table>
<p>在同一个请求 URL 中可以同时使用排序、筛选和分页，也可以一次应用多个筛选条件。</p>
<h2 id=校验请求>校验请求</h2>
<p>在交互式程序开发中，永远不要忘记
<a href=/docs/security/validation/>校验/验证</a> 用户输入的数据。在 Spiral 中你可以使用更底层的 ValidationInterface 或者
<a href=/docs/filters/configuration/>请求过滤（Request Filters）</a> 来对用户输入进行过滤、验证和映射。</p>
<p>我们的示例应用允许用户提交评论，通过
<a href=/docs/basics/scaffolding/>脚手架命令</a>创建一个 <code>CommentFilter</code> 类：</p>
<pre><code class=language-bash>$ php app.php create:filter comment
</code></pre>
<p>按照下面的示例来配置过滤类：</p>
<pre><code class=language-php>namespace App\Filter;

use Spiral\Filters\Filter;

class CommentFilter extends Filter
{
    protected const SCHEMA = [
        'message' =&gt; 'data:message'
    ];

    protected const VALIDATES = [
        'message' =&gt; ['notEmpty']
    ];

    protected const SETTERS = [
        'message' =&gt; 'strval'
    ];

    public function getMessage(): string
    {
        return $this-&gt;message;
    }
}
</code></pre>
<h3 id=评论服务类>评论服务类</h3>
<p>创建 <code>App\Service\CommentService</code>:</p>
<pre><code class=language-php>namespace App\Service;

use App\Database\Comment;
use App\Database\Post;
use App\Database\User;
use Cycle\ORM\TransactionInterface;
use Spiral\Prototype\Annotation\Prototyped;

/**
 * @Prototyped(property=&quot;commentService&quot;)
 */
class CommentService
{
    private $tr;

    public function __construct(TransactionInterface $tr)
    {
        $this-&gt;tr = $tr;
    }

    public function comment(Post $post, User $user, string $message): Comment
    {
        $comment = new Comment();
        $comment-&gt;post = $post;
        $comment-&gt;author = $user;
        $comment-&gt;message = $message;

        $this-&gt;tr-&gt;persist($comment);
        $this-&gt;tr-&gt;run();

        return $comment;
    }
}
</code></pre>
<h3 id=控制器方法>控制器方法</h3>
<p>在 <code>PostController</code> 控制器中定义发表评论的 <code>comment</code> 方法，通过方法注入获得请求过滤类的实例。由于之前我们在领域核心中配置了 <code>FilterInterceptor</code> 拦截器，Spiral 框架会保证过滤器的有效性。</p>
<p>参考下面的代码来实现 <code>comment</code> 端点，可以对指定的文章发表评论：</p>
<pre><code class=language-php>/**
 * @Route(action=&quot;/api/post/&lt;post:\d+&gt;/comment&quot;, verbs={&quot;POST&quot;})
 * @param Post          $post
 * @param CommentFilter $commentFilter
 * @return array
 */
public function comment(Post $post, CommentFilter $commentFilter)
{
    $this-&gt;commentService-&gt;comment(
        $post,
        $this-&gt;users-&gt;findOne(), // todo: use current user
        $commentFilter-&gt;getMessage()
    );

    return ['status' =&gt; 201];
}
</code></pre>
<blockquote>
<p>如果需要实现 HTML 格式的 AJAX 原生表单，可以使用 <code>spiral/toolkit</code> 扩展。</p>
</blockquote>
<h3 id=执行和验证>执行和验证</h3>
<p>我们直接通过 curl 命令来检查我们的 API 功能是否正确：</p>
<pre><code class=language-bash>$ curl -X POST -H 'content-type: application/json' --data '{}' http://localhost:8080/api/post/1/comment
</code></pre>
<p>响应内容：</p>
<pre><code class=language-json>{ &quot;status&quot;: 400, &quot;errors&quot;: { &quot;message&quot;: &quot;This value is required.&quot; } }
</code></pre>
<p>或者当指定 ID 的文章不存在时响应 404:</p>
<pre><code class=language-bash>$ curl -X POST -H 'content-type: application/json' --data '{&quot;message&quot;:&quot;some comment&quot;}' http://localhost:8080/api/post/9999/comment
</code></pre>
<blockquote>
<p>要获取 JSON 格式的错误响应，别忘了在请求头信息加上 <code>accept: application/json</code>.</p>
</blockquote>
<p>发表一个有效的评论：</p>
<pre><code class=language-bash>$ curl -X POST -H 'content-type: application/json' --data '{&quot;message&quot;: &quot;first comment&quot;}' http://localhost:8080/api/post/1/comment
</code></pre>
<blockquote>
<p>关于请求过滤的更多信息，请阅读
<a href=/docs/filters/filter/>相关文档</a>. 也可以通过改变
<a href=/docs/basics/scaffolding/>脚手架</a>的配置来修改生成的请求处理逻辑或者默认命名空间。</p>
</blockquote>
<h2 id=渲染模板>渲染模板</h2>
<p>虽然我们的演示程序是设计 Restful API 应用，不过作为教程，还是向大家介绍一下视图模板渲染的方法。</p>
<p>在 Spiral 中，可以使用
<a href=/docs/views/configuration/>视图</a> 和
<a href=/docs/stempler/configuration/>Stempler</a> 组件来把数据渲染成 HTML 页面。在渲染列表页时可以直接把数据网格对象传递给模板。</p>
<p>先创建一个控制器方法来处理 <code>http://localhost:8080/posts</code> 的请求：</p>
<pre><code class=language-php>/**
 * @Route(action=&quot;/posts&quot;, verbs={&quot;GET&quot;})
 * @param GridFactory $grids
 * @return string
 */
public function all(GridFactory $grids): string
{
    $grid = $grids-&gt;create($this-&gt;posts-&gt;findAllWithAuthor(), $this-&gt;postGrid);

    return $this-&gt;views-&gt;render('posts', ['posts' =&gt; $grid]);
}
</code></pre>
<h3 id=创建布局>创建布局</h3>
<p>创建或者编辑位于 <code>app/views/layout/base.dark.php</code> 的布局文件：</p>
<pre><code class=language-html>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;${title}&lt;/title&gt;
    &lt;link
      href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css&quot;
      rel=&quot;stylesheet&quot;
    /&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;container&quot;&gt;
      &lt;block:body /&gt;
    &lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3 id=文章列表页面>文章列表页面</h3>
<p>然后在 <code>app/views/posts.dark.php</code> 创建一个视图模板，继承刚才的布局。</p>
<pre><code class=language-html>&lt;extends:layout.base title=&quot;Posts&quot; /&gt;

&lt;define:body&gt;
  @foreach($posts as $post)
  &lt;div class=&quot;post&quot;&gt;
    &lt;div class=&quot;title&quot;&gt;{{$post-&gt;title}}&lt;/div&gt;
    &lt;div class=&quot;author&quot;&gt;{{$post-&gt;author-&gt;name}}&lt;/div&gt;
  &lt;/div&gt;
  @endforeach
&lt;/define:body&gt;
</code></pre>
<blockquote>
<p>现在打开 <code>http://localhost:8080/posts</code> 就能看到文章列表，可以参考之前讲数据网格时介绍的 URL 查询参数来控制数据的筛选、排序、分页（比如 <code>http://localhost:8080/posts?paginate[page]=2</code>）</p>
</blockquote>
<h3 id=文章详情页>文章详情页</h3>
<p>要实现查看某篇文章和它的评论，在 <code>PostController</code> 控制器中创建一个新的控制器方法，通过数据仓库类手动加载文章并预加载它的作者和评论信息。</p>
<pre><code class=language-php>use Spiral\Http\Exception\ClientException\NotFoundException;
// ...

/**
 * @Route(action=&quot;/post/&lt;id:\d+&gt;&quot;, verbs={&quot;GET&quot;})
 * @param string $id
 * @return string
 */
public function view(string $id): string
{
    $post = $this-&gt;posts-&gt;findOneWithComments($id);
    if ($post === null) {
        throw new NotFoundException();
    }

    return $this-&gt;views-&gt;render('post', ['post' =&gt; $post]);
}
</code></pre>
<p>调用的仓库方法 <code>findOneWithComments($id)</code> 代码如下：</p>
<pre><code class=language-php>public function findOneWithComments(string $id): ?Post
{
    return $this
        -&gt;select()
        -&gt;wherePK($id)
        -&gt;load('author')
        -&gt;load(
            'comments.author',
            [
                'load' =&gt; function (Select\QueryBuilder $q) {
                    // last comments at top
                    $q-&gt;orderBy('id', 'DESC');
                }
            ]
        )
        -&gt;fetchOne();
}
</code></pre>
<p>类似地，创建 <code>app/views/post.dark.php</code> 模板：</p>
<pre><code class=language-html>&lt;extends:layout.base title=&quot;Posts&quot; /&gt;

&lt;define:body&gt;
  &lt;div class=&quot;post&quot;&gt;
    &lt;div class=&quot;title&quot;&gt;{{$post-&gt;title}}&lt;/div&gt;
    &lt;div class=&quot;author&quot;&gt;{{$post-&gt;author-&gt;name}}&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;comments&quot;&gt;
    @foreach($post-&gt;comments as $comment)
    &lt;div class=&quot;comment&quot;&gt;
      &lt;div class=&quot;message&quot;&gt;{{$comment-&gt;message}}&lt;/div&gt;
      &lt;div class=&quot;author&quot;&gt;{{$comment-&gt;author-&gt;name}}&lt;/div&gt;
    &lt;/div&gt;
    @endforeach
  &lt;/div&gt;
&lt;/define:body&gt;
</code></pre>
<p>Open the post page using <code>http://localhost:8080/post/1</code>.</p>
<blockquote>
<p>网页的样式以及评论的时间显示就留给读者自己完成了。</p>
</blockquote>
<h3 id=路由-1>路由</h3>
<p>在之前有关注解式路由的部分，我们在路由引导程序 <code>RoutesBootloader</code> 中为每个注解式路由都按照 <code>ControllerName.methodName</code> 的格式做了命名。所以在模板中要生成指向 <code>PostController</code> 的 <code>view</code> 方法的链接时，对应的路由名称为 <code>PostController.view</code>:</p>
<pre><code class=language-html>&lt;extends:layout.base title=&quot;Posts&quot; /&gt;

&lt;define:body&gt;
  @foreach($posts as $post)
  &lt;div class=&quot;post&quot;&gt;
    &lt;div class=&quot;title&quot;&gt;
      &lt;a href=&quot;@route('PostController.view', ['id' =&gt; $post-&gt;id])&quot;
        &gt;{{$post-&gt;title}}&lt;/a
      &gt;
    &lt;/div&gt;
    &lt;div class=&quot;author&quot;&gt;{{$post-&gt;author-&gt;name}}&lt;/div&gt;
  &lt;/div&gt;
  @endforeach
&lt;/define:body&gt;
</code></pre>
<blockquote>
<p>有关 Stempler 模板引擎的指令，请阅读
<a href=/docs/stempler/directives/>Stempler 的文档</a>。</p>
</blockquote>
<h2 id=后续步骤>后续步骤</h2>
<p>Spiral 框架为你提供了大量预先构建好的强大功能。请参考以下文档以了解更多：</p>
<ul>
<li>
<a href=/docs/security/authentication/>用户身份验证</a></li>
<li>
<a href=/docs/security/rbac/>基于角色的访问鉴权</a></li>
<li>
<a href=/docs/queue/configuration/>后台任务</a></li>
</ul>
<h2 id=教程源码>教程源码</h2>
<p>本教程的示例项目的最终源码：
<a href=https://github.com/spiral/demo target=_blank rel=noopener>https://github.com/spiral/demo</a></p>
<p>代码下载到本地之后，别忘记先执行以下命令：</p>
<pre><code class=language-bash>$ composer install
$ vendor/bin/spiral get
$ php app.php configure
</code></pre>
</div>
<div class=article-widget>
<div class=post-nav>
<div class=post-nav-item>
<div class=meta-nav>上一页</div>
<a href=/docs/http/routing/ rel=next>路由</a>
</div>
<div class=post-nav-item>
<div class=meta-nav>下一页</div>
<a href=/docs/cookbook/annotated-routes/ rel=prev>注解式路由</a>
</div>
</div>
</div>
</div>
<div class=body-footer>
<p>最近更新于 2020-04-13</p>
<p class=edit-page>
<a href=https://github.com/krwu/spiraldocs/edit/feat/chinese/zh_CN/basics/quick-start.md>
<i class="fas fa-pen pr-2"></i>编辑本页
</a>
</p>
</div>
</article>
<footer class=site-footer>
<p class=powered-by><span class=copyright>© 2019 - 2021 <a href=https://studyspiral.cn/>StudySpiral</a> All rights reserved.</span>
</p>
<p class=powered-by>
<a href=http://www.beian.miit.gov.cn/ target=_blank rel="noopener noreferer">粤ICP备14011364号</a>
<a href="http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=44030402001866" target=_blank rel="noopener noreferer"><img style=display:inline-block src=/img/gaba.png>粤公网安备 44030402001866号</a>
</p>
<p id=webify class=powered-by style=display:none>[<a href=https://webify.cloudbase.net/ target=_blank rel=noopener>CloudBase Webify</a>] 提供网站托管服务</p>
<script async defer>window.addEventListener('DOMContentLoaded',()=>{const{host:a}=window.location;a.toLocaleLowerCase()==='docs.studyspiral.cn'&&(document.getElementById('webify').style.display='block')})</script>
</footer>
</main>
</div>
</div>
<script src=https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/jquery.imagesloaded/4.1.4/imagesloaded.pkgd.min.js integrity="sha256-lqvxZrPLtfffUl2G/e7szqSvPBILGbwmsGE1MKlOi0Q=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/jquery.isotope/3.0.6/isotope.pkgd.min.js integrity="sha256-CBrpuqrMhXwcLLUd5tvQ4euBHCdh7wGlDfNz8vbu/iI=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.7/jquery.fancybox.min.js integrity="sha256-yt2kYMy0w8AbtF89WXb2P1rfjcP/HTHLT7097U8Y5b8=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/highlight.min.js integrity="sha256-eOgo0OtLL4cdq7RdwRUiGKLX9XsIJ7nGhWEKbohmVAQ=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/languages/go.min.js></script>
<script>const code_highlighting=!0</script>
<script>const isSiteThemeDark=!1</script>
<script>const search_config={indexURI:"/index.json",minLength:1,threshold:.3},i18n={no_results:"没有找到结果",placeholder:"搜索...",results:"搜索结果"},content_type={post:"文章",project:"项目",publication:"出版物",talk:"演讲"}</script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/anchor-js/4.1.1/anchor.min.js integrity="sha256-pB/deHc9CGfFpJRjC43imB29Rse8tak+5eXqntO94ck=" crossorigin=anonymous></script>
<script>anchors.add()</script>
<script id=search-hit-fuse-template type=text/x-template>
      <div class="search-hit" id="summary-{{key}}">
      <div class="search-hit-content">
        <div class="search-hit-name">
          <a href="{{relpermalink}}">{{title}}</a>
          <div class="article-metadata search-hit-type">{{type}}</div>
          <p class="search-hit-description">{{snippet}}</p>
        </div>
      </div>
      </div>
    </script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.1/fuse.min.js integrity="sha256-VzgmKYmhsGNNN4Ph1kMW+BjoYJM2jV5i4IlFoeZA9XI=" crossorigin=anonymous></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js integrity="sha256-4HLtjeVgH0eIB3aZ9mLYF6E8oU5chNdjU6p6rrXpl9U=" crossorigin=anonymous></script>
<script src=/js/academic.min.6f1005d1a84220e2f466ff3d8e712f31.js></script>
<div id=modal class="modal fade" role=dialog>
<div class=modal-dialog>
<div class=modal-content>
<div class=modal-header>
<h5 class=modal-title>引用</h5>
<button type=button class=close data-dismiss=modal aria-label=Close>
<span aria-hidden=true>&#215;</span>
</button>
</div>
<div class=modal-body>
<pre><code class="tex hljs"></code></pre>
</div>
<div class=modal-footer>
<a class="btn btn-outline-primary my-1 js-copy-cite" href=# target=_blank>
<i class="fas fa-copy"></i> 复制
</a>
<a class="btn btn-outline-primary my-1 js-download-cite" href=# target=_blank>
<i class="fas fa-download"></i> 下载
</a>
<div id=modal-error></div>
</div>
</div>
</div>
</div>
</body>
</html>